Improve live activity routing and diagnostics#3685
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Missing database migration columns
- Added the drizzle-kit-generated Postgres migration (migration.sql + snapshot.json) adding bundle_id and aps_environment to relay_mobile_devices, chained onto the previous snapshot.
- ✅ Fixed: Last delivery ignores HTTP status
- The Last Delivery row now treats a non-2xx lastDeliveryStatus as a failure even when lastDeliveryError is null, showing "Delivery failed (status)" instead of "Delivered".
- ✅ Fixed: Diagnostics use global attempt window
- Replaced the global 100-row window with a DISTINCT ON (device_id) query ordered by created_at DESC so each device's true latest delivery attempt is always returned.
Or push these changes by commenting:
@cursor push a97e0b6881
Preview (a97e0b6881)
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.test.ts
@@ -69,6 +69,28 @@
]);
});
+ it("treats a non-2xx delivery status without a reason as a failure", () => {
+ const rows = formatDeviceDiagnosticsRows(
+ makeDevice({
+ bundleId: "com.t3tools.t3code.preview",
+ apsEnvironment: "production",
+ hasPushToken: true,
+ hasPushToStartToken: true,
+ hasLiveActivityToken: true,
+ lastDeliveryAt: "2026-06-05T01:02:59.566Z",
+ lastDeliveryKind: "live_activity_end",
+ lastDeliveryStatus: 400,
+ lastDeliveryError: null,
+ }),
+ );
+
+ expect(rows[4]).toEqual({
+ label: "Last Delivery",
+ value: "Delivery failed (400)",
+ tone: "warn",
+ });
+ });
+
it("reports healthy registrations with a successful delivery", () => {
const rows = formatDeviceDiagnosticsRows(
makeDevice({
diff --git a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
--- a/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
+++ b/apps/mobile/src/features/agent-awareness/deviceDiagnostics.ts
@@ -73,14 +73,21 @@
});
}
+ // APNs can fail with a non-2xx status without a reason body, so a null
+ // error alone does not mean the delivery succeeded.
+ const deliveryFailed =
+ diagnostics.lastDeliveryError !== null ||
+ (diagnostics.lastDeliveryStatus !== null &&
+ (diagnostics.lastDeliveryStatus < 200 || diagnostics.lastDeliveryStatus >= 300));
+
if (diagnostics.lastDeliveryAt === null) {
rows.push({ label: "Last Delivery", value: "None yet", tone: "muted" });
- } else if (diagnostics.lastDeliveryError !== null) {
+ } else if (deliveryFailed) {
const status =
diagnostics.lastDeliveryStatus === null ? "" : ` (${diagnostics.lastDeliveryStatus})`;
rows.push({
label: "Last Delivery",
- value: `${diagnostics.lastDeliveryError}${status}`,
+ value: `${diagnostics.lastDeliveryError ?? "Delivery failed"}${status}`,
tone: "warn",
});
} else {
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/migration.sql
@@ -1,0 +1,3 @@
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "bundle_id" varchar(255);
+--> statement-breakpoint
+ALTER TABLE "relay_mobile_devices" ADD COLUMN "aps_environment" varchar(16);
diff --git a/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
new file mode 100644
--- /dev/null
+++ b/infra/relay/migrations/postgres/20260704031500_add_mobile_device_apns_route/snapshot.json
@@ -1,0 +1,1479 @@
+{
+ "dialect": "postgres",
+ "id": "e00cc7df-a31e-4b8b-b258-d04e7db7758a",
+ "prevIds": ["385d476b-d4f6-48a3-99e6-0a95af4ee4e4"],
+ "version": "8",
+ "ddl": [
+ {
+ "isRlsEnabled": false,
+ "name": "relay_agent_activity_rows",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_delivery_attempts",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_dpop_proofs",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_environment_credentials",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_environment_links",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_live_activities",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_managed_endpoint_allocations",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "relay_mobile_devices",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_public_key",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "thread_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "state_json",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_agent_activity_rows"
+ },
+ {
+ "type": "varchar(36)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "thread_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "device_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "kind",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "source_job_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(16)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "token_suffix",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "apns_status",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "apns_reason",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(128)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "apns_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "transport_error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_delivery_attempts"
+ },
+ {
+ "type": "varchar(128)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "thumbprint",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "jti",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "iat",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "expires_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_dpop_proofs"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "credential_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_public_key",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "credential_hash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "revoked_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_credentials"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'T3 Environment'",
+ "generated": null,
+ "identity": null,
+ "name": "environment_label",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "environment_public_key",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "endpoint_http_base_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "endpoint_ws_base_url",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(32)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "endpoint_provider_kind",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "notifications_enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "true",
+ "generated": null,
+ "identity": null,
+ "name": "live_activities_enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "boolean",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "false",
+ "generated": null,
+ "identity": null,
+ "name": "managed_tunnels_enabled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(191)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_by_device_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "revoked_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "created_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updated_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_environment_links"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(255)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "device_id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "text",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "activity_push_token",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "remote_start_queued_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "remote_started_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "varchar(64)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "ended_at",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "relay_live_activities"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
... diff truncated: showing 800 of 1681 linesYou can send follow-ups to the cloud agent here.
| platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), | ||
| iosMajorVersion: integer("ios_major_version").notNull(), | ||
| appVersion: varchar("app_version", { length: 64 }), | ||
| bundleId: varchar("bundle_id", { length: 255 }), |
There was a problem hiding this comment.
🟠 High persistence/schema.ts:27
The new bundle_id and aps_environment columns are added to the relay_mobile_devices schema, but no SQL migration is included to create them. After deploy, the code in Devices.ts immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with column "bundle_id" does not exist (and aps_environment likewise). Add the corresponding ALTER TABLE relay_mobile_devices ADD COLUMN ... migration under infra/relay/migrations/ so the columns exist before the new code runs.
Also found in 1 other location(s)
infra/relay/src/agentActivity/LiveActivities.ts:201
The new
relayMobileDevices.bundleId/relayMobileDevices.apsEnvironmentcolumns are referenced bylistTargets(bundle_id,aps_environment) and other relay code, but this PR does not add a matching SQL migration forrelay_mobile_devices. After deploying the code to an existing database, anylistTargetsquery will start failing withcolumn relay_mobile_devices.bundle_id does not exist(and likewise foraps_environment), breaking live-activity target lookup until the schema is manually patched.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/persistence/schema.ts around line 27:
The new `bundle_id` and `aps_environment` columns are added to the `relay_mobile_devices` schema, but no SQL migration is included to create them. After deploy, the code in `Devices.ts` immediately inserts and selects these columns, so PostgreSQL rejects every device registration and list query with `column "bundle_id" does not exist` (and `aps_environment` likewise). Add the corresponding `ALTER TABLE relay_mobile_devices ADD COLUMN ...` migration under `infra/relay/migrations/` so the columns exist before the new code runs.
Also found in 1 other location(s):
- infra/relay/src/agentActivity/LiveActivities.ts:201 -- The new `relayMobileDevices.bundleId` / `relayMobileDevices.apsEnvironment` columns are referenced by `listTargets` (`bundle_id`, `aps_environment`) and other relay code, but this PR does not add a matching SQL migration for `relay_mobile_devices`. After deploying the code to an existing database, any `listTargets` query will start failing with `column relay_mobile_devices.bundle_id does not exist` (and likewise for `aps_environment`), breaking live-activity target lookup until the schema is manually patched.
There was a problem hiding this comment.
🟡 Medium
The stale-job check in processSignedJob only compares the token via isCurrentSignedJobToken, so a queued job created before the device registered bundleId/apsEnvironment (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with BadDeviceToken/DeviceTokenNotForTopic even though the registration has already been corrected. Consider comparing bundleId and apsEnvironment against the current target in the staleness check so jobs with outdated routing are treated as stale.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 506:
The stale-job check in `processSignedJob` only compares the token via `isCurrentSignedJobToken`, so a queued job created before the device registered `bundleId`/`apsEnvironment` (or before those values changed) still passes the staleness guard when the token is unchanged. The job is then sent with the stale or missing routing metadata from the signed payload instead of the device's current values, causing APNs to reject the delivery with `BadDeviceToken`/`DeviceTokenNotForTopic` even though the registration has already been corrected. Consider comparing `bundleId` and `apsEnvironment` against the current target in the staleness check so jobs with outdated routing are treated as stale.
ApprovabilityVerdict: Needs human review 20 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR. You can customize Macroscope's approvability policy. Learn more. |
f81c2ca to
1d6309e
Compare
| const deepLinkRow = attentionRow ?? row0; | ||
| const deepLink = | ||
| deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") | ||
| ? `t3code://${deepLinkRow.deepLink.slice(1)}` |
There was a problem hiding this comment.
🟡 Medium widgets/AgentActivity.tsx:81
widgetURL is hardcoded to the t3code:// scheme, but the app registers t3code-dev for development builds and t3code-preview for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 81:
`widgetURL` is hardcoded to the `t3code://` scheme, but the app registers `t3code-dev` for development builds and `t3code-preview` for preview builds. On dev/preview builds the widget banner's tap URL uses a scheme the containing app does not register, so iOS will not open the app when the banner is tapped. Consider using a scheme that is registered in all build variants (or injecting the active scheme per-build).
| // while a user ignores an approval prompt, so they get a longer window. The | ||
| // underlying database row is left in place: a late publish for the thread | ||
| // refreshes updatedAt and the row becomes visible again. | ||
| const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000; |
There was a problem hiding this comment.
🟡 Medium agentActivity/AgentActivityPublisher.ts:203
RUNNING_AGENT_ACTIVITY_ROW_TTL_MS expires running/starting states after 2 hours based on updatedAt, but relay publishes are event-driven — a thread that stays in the same running phase for over 2 hours without a new event keeps its old updatedAt, so isExpiredAgentActivityState returns true and makeAggregateState filters it out. The aggregate then reports activeCount: 0 (or null/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/AgentActivityPublisher.ts around line 203:
`RUNNING_AGENT_ACTIVITY_ROW_TTL_MS` expires `running`/`starting` states after 2 hours based on `updatedAt`, but relay publishes are event-driven — a thread that stays in the same `running` phase for over 2 hours without a new event keeps its old `updatedAt`, so `isExpiredAgentActivityState` returns `true` and `makeAggregateState` filters it out. The aggregate then reports `activeCount: 0` (or `null`/terminal) even though the job is still running, hiding active work from clients. Consider raising the running TTL to match the waiting TTL, or filtering on a heartbeat/last-seen timestamp that is refreshed independently of phase-change events.
1d6309e to
39d9dca
Compare
This comment has been minimized.
This comment has been minimized.
39d9dca to
5225bdb
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Registration status stuck pending
- The device-registration queue completion handler now resets a status still left as 'pending' back to 'unknown' when the effect exits without reaching the relay (signed out, missing relay config, cancelled generation, or unsupported platform).
- ✅ Fixed: Sign-out cleanup on provider clear
- Added a non-destructive suspend path (suspendAgentAwarenessRelayTokenProvider / suspendCloudRelayAccount) used by CloudAuthBridge unmount and missing-config teardown so Live Activities and the persisted registration record survive while the user remains signed in, keeping destructive cleanup only for real sign-out and account switches.
Or push these changes by commenting:
@cursor push fa19061765
Preview (fa19061765)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -143,6 +143,30 @@
return identity === undefined || identity !== previousIdentity;
}
+function removeRelaySubscriptions(): void {
+ pushToStartSubscription?.remove();
+ pushToStartSubscription = null;
+ pushTokenSubscription?.remove();
+ pushTokenSubscription = null;
+ appStateSubscription?.remove();
+ appStateSubscription = null;
+ if (activeLiveActivityRegistrationRetry) {
+ clearTimeout(activeLiveActivityRegistrationRetry);
+ activeLiveActivityRegistrationRetry = null;
+ }
+}
+
+// Drops the relay token provider without sign-out semantics: the Clerk session
+// can still be valid while the auth bridge tears down (remounts, provider tree
+// changes, missing cloud config). Listeners stop, but local Live Activities,
+// the persisted registration record, and the provider identity stay intact so
+// re-activating the same account does not end activities or force
+// re-registration.
+export function suspendAgentAwarenessRelayTokenProvider(): void {
+ relayTokenProvider = null;
+ removeRelaySubscriptions();
+}
+
export function setAgentAwarenessRelayTokenProvider(
provider: (() => Promise<string | null>) | null,
identity?: string,
@@ -158,16 +182,7 @@
relayTokenProvider = provider;
relayTokenProviderIdentity = provider ? (identity ?? null) : null;
if (!provider) {
- pushToStartSubscription?.remove();
- pushToStartSubscription = null;
- pushTokenSubscription?.remove();
- pushTokenSubscription = null;
- appStateSubscription?.remove();
- appStateSubscription = null;
- if (activeLiveActivityRegistrationRetry) {
- clearTimeout(activeLiveActivityRegistrationRetry);
- activeLiveActivityRegistrationRetry = null;
- }
+ removeRelaySubscriptions();
// Without a signed-in user the relay can no longer update or end these
// activities, so they would sit orphaned on the lock screen.
endLocalLiveActivities("live activity cleanup after cloud sign-out failed");
@@ -472,6 +487,11 @@
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
setRegistrationStatus("failed");
logRegistrationError(next.context, squashAtomCommandFailure(result));
+ } else if (registrationStatus === "pending") {
+ // The registration exited without reaching the relay (signed out,
+ // missing relay config, or superseded by a newer generation); don't
+ // leave the status stuck on pending.
+ setRegistrationStatus("unknown");
}
logRegistrationDebug("device registration finished", { generation });
if (activeDeviceRegistration === registration) {
diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
--- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
+++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx
@@ -15,6 +15,7 @@
import { useAtomCommand } from "../../state/use-atom-command";
import {
setAgentAwarenessRelayTokenProvider,
+ suspendAgentAwarenessRelayTokenProvider,
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
@@ -32,6 +33,14 @@
setManagedRelaySession(appAtomRegistry, null);
}
+// Teardown without sign-out semantics: the Clerk session may still be valid
+// (bridge remounts, provider tree changes, missing cloud config), so local
+// Live Activities and the persisted registration record must survive.
+export function suspendCloudRelayAccount(): void {
+ suspendAgentAwarenessRelayTokenProvider();
+ setManagedRelaySession(appAtomRegistry, null);
+}
+
export function activateCloudRelayAccount(
accountId: string,
tokenProvider: () => Promise<string | null>,
@@ -143,7 +152,7 @@
useEffect(
() => () => {
previousTokenProviderRef.current = null;
- deactivateCloudRelayAccount();
+ suspendCloudRelayAccount();
},
[],
);
@@ -158,7 +167,7 @@
useEffect(() => {
if (!publishableKey || !relayUrl) {
- deactivateCloudRelayAccount();
+ suspendCloudRelayAccount();
}
}, [publishableKey, relayUrl]);You can send follow-ups to the cloud agent here.
| // Only reads as on when this device is actually registered with the | ||
| // relay; otherwise notifications cannot be delivered regardless of | ||
| // the local iOS permission. | ||
| value={notificationStatus === "enabled" && deviceRegistered} |
There was a problem hiding this comment.
🟡 Medium settings/SettingsRouteScreen.tsx:438
The deviceRegistered gate on the Device Notifications switch renders it false when relay registration fails, even though notificationStatus is "enabled". Because the switch shows off, the user cannot toggle it to reach handleDeviceNotificationsChange(false), which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on deviceRegistered, or keeping the Device Notifications switch reflecting the actual iOS permission state so the user can still navigate to system Settings.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 438:
The `deviceRegistered` gate on the `Device Notifications` switch renders it `false` when relay registration fails, even though `notificationStatus` is `"enabled"`. Because the switch shows off, the user cannot toggle it to reach `handleDeviceNotificationsChange(false)`, which is the only path that opens iOS Settings to disable notifications. In the "permission granted, relay registration failed" case the switch misreports notifications as off and removes the in-app affordance for managing them. Consider gating only the Live Activity switch on `deviceRegistered`, or keeping the `Device Notifications` switch reflecting the actual iOS permission state so the user can still navigate to system Settings.
| userId: input.userId, | ||
| deviceId: input.deviceId, | ||
| token: input.token, | ||
| ...(input.bundleId ? { bundleId: input.bundleId } : {}), |
There was a problem hiding this comment.
🟠 High agentActivity/apnsDeliveryJobs.ts:247
makeApnsDeliveryJobPayload includes target.bundleId and target.apsEnvironment in the payload, and signatureForPayload signs that full object. Older relay workers that decode with a previous ApnsDeliveryJobPayload schema strip the new keys on decode (default effect Schema.Struct behavior) and recompute the HMAC over the reduced payload, so verifySignedApnsDeliveryJob returns ApnsDeliveryJobSignatureInvalid for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/apnsDeliveryJobs.ts around line 247:
`makeApnsDeliveryJobPayload` includes `target.bundleId` and `target.apsEnvironment` in the payload, and `signatureForPayload` signs that full object. Older relay workers that decode with a previous `ApnsDeliveryJobPayload` schema strip the new keys on decode (default `effect` `Schema.Struct` behavior) and recompute the HMAC over the reduced payload, so `verifySignedApnsDeliveryJob` returns `ApnsDeliveryJobSignatureInvalid` for every newly queued job carrying per-device routing. This breaks APNs delivery during rolling deploys when new producers share a queue with old consumers. Consider gating inclusion of these fields behind a flag, or otherwise ensuring the signed payload shape remains decodable by older schema versions, so signatures stay stable across the rollout.
5225bdb to
5086c14
Compare
There was a problem hiding this comment.
🟡 Medium
t3code/apps/mobile/app.config.ts
Lines 96 to 97 in 5086c14
The frequentUpdates: true option under the expo-widgets plugin does not add the required NSSupportsLiveActivitiesFrequentUpdates key to Info.plist, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set NSSupportsLiveActivitiesFrequentUpdates: true in the ios.infoPlist block to actually grant the entitlement.
infoPlist: {
+ NSSupportsLiveActivitiesFrequentUpdates: true,
NSAppTransportSecurity: {🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:
The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.
| return existing?.trim() ? existing : null; | ||
| } | ||
|
|
||
| export interface AgentAwarenessRegistrationRecord { |
There was a problem hiding this comment.
🟡 Medium lib/storage.ts:228
AgentAwarenessRegistrationRecord stores only identity and signature, so when the relay base URL changes but the user identity and device payload stay the same, loadAgentAwarenessRegistrationRecord returns the stale record and the app skips registration against the new relay. The new relay never receives this device's registration until sign-out clears the record. Consider including the relay base URL (or a hash of it) in the persisted record so a URL change invalidates the cached registration.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/lib/storage.ts around line 228:
`AgentAwarenessRegistrationRecord` stores only `identity` and `signature`, so when the relay base URL changes but the user identity and device payload stay the same, `loadAgentAwarenessRegistrationRecord` returns the stale record and the app skips registration against the new relay. The new relay never receives this device's registration until sign-out clears the record. Consider including the relay base URL (or a hash of it) in the persisted record so a URL change invalidates the cached registration.
| value={notificationStatus === "enabled" && deviceRegistered} | ||
| onValueChange={handleDeviceNotificationsChange} | ||
| /> | ||
| <SettingsSwitchRow |
There was a problem hiding this comment.
🟠 High settings/SettingsRouteScreen.tsx:441
When deviceRegistered is false but liveActivitiesEnabled is true in storage, the Live Activity Updates switch renders off even though liveActivityStatus is "enabled". Because onValueChange receives the next visual state (true), tapping the switch calls linkEnvironments() (the enable path) instead of persisting enabled: false, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on deviceRegistered rather than the switch value, so the toggle still reflects the saved preference and onValueChange fires with the correct next state.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/settings/SettingsRouteScreen.tsx around line 441:
When `deviceRegistered` is `false` but `liveActivitiesEnabled` is `true` in storage, the `Live Activity Updates` switch renders off even though `liveActivityStatus` is `"enabled"`. Because `onValueChange` receives the next visual state (`true`), tapping the switch calls `linkEnvironments()` (the enable path) instead of persisting `enabled: false`, so the user cannot disable Live Activity updates from Settings until registration succeeds. Consider gating the disabled state or the alert on `deviceRegistered` rather than the switch `value`, so the toggle still reflects the saved preference and `onValueChange` fires with the correct next state.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
There are 6 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared fixes for 2 of the 3 issues found in the latest run.
- ✅ Fixed: Foreground replay ends live activities
- Replay now skips delivery when the aggregate is null but non-terminal rows still exist, so TTL-expired rows from a healthy long-running agent no longer trigger live_activity_end on foreground while truly orphaned activities (rows removed) are still ended.
- ✅ Fixed: Same identity skips registration retry
- setAgentAwarenessRelayTokenProvider now only skips re-enqueueing device registration for an unchanged identity when the registration status is "registered", so failed or stalled registrations retry when the auth effect re-runs.
Or push these changes by commenting:
@cursor push 59517fdb7d
Preview (59517fdb7d)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -186,7 +186,11 @@
refreshActiveLiveActivityRemoteRegistration(),
"active live activity registration after cloud sign-in failed",
);
- if (isExistingIdentity) {
+ // An unchanged identity only skips re-registration when the previous attempt
+ // actually reached the relay; a failed or stalled attempt must retry the next
+ // time the auth effect re-runs, or the device stays unregistered until an
+ // unrelated event (token rotation, environment link) happens to enqueue one.
+ if (isExistingIdentity && registrationStatus === "registered") {
return;
}
enqueueDeviceRegistration({}, "device registration after cloud sign-in failed");
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -119,6 +119,16 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A null aggregate is ambiguous while non-terminal rows still exist:
+ // they may belong to a dead environment, or to a healthy long-running
+ // agent whose meaningful state (and therefore updatedAt) has not changed
+ // within the TTL. Sending in that case would end a possibly-healthy Live
+ // Activity on every foreground, so replay only delivers when live rows
+ // remain or the rows are truly gone (the thread ended and its end push
+ // may have been lost).
+ if (aggregate === null && activeStates.some((state) => !isTerminalPhase(state))) {
+ return null;
+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,You can send follow-ups to the cloud agent here.
5086c14 to
a31735c
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 7 total unresolved issues (including 6 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Parallel registration marks failed incorrectly
- Registration attempts now capture a success counter at start and only mark the status failed when no concurrent attempt succeeded while they were in flight, so a stale failure can no longer overwrite a relay-accepted registration.
Or push these changes by commenting:
@cursor push d469c8569a
Preview (d469c8569a)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
@@ -535,6 +535,48 @@
}).pipe(Effect.provide(relayTestLayer));
});
+ it.effect("keeps the registered status when a stale concurrent attempt fails", () => {
+ const fetchMock = vi.fn((request: RequestInfo | URL) => {
+ const url = request instanceof Request ? request.url : String(request);
+ return Promise.resolve(
+ Response.json(
+ url.endsWith("/v1/client/dpop-token")
+ ? {
+ access_token: "relay-dpop-token",
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ token_type: "DPoP",
+ expires_in: 300,
+ scope: "mobile:registration",
+ }
+ : { ok: true },
+ ),
+ );
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ Constants.expoConfig!.extra = {
+ relay: {
+ url: "https://relay.example.test/",
+ },
+ };
+
+ // Sign-in enqueues a registration attempt that stays parked in the
+ // background queue while the direct refresh below runs.
+ setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
+
+ return Effect.gen(function* () {
+ yield* refreshAgentAwarenessRegistration();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+
+ // The queued sign-in attempt now fails; its stale failure must not
+ // overwrite the registration the relay already accepted.
+ vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce(
+ new Error("device id unavailable"),
+ );
+ yield* runBackgroundOperations();
+ expect(getAgentAwarenessRegistrationStatus()).toBe("registered");
+ }).pipe(Effect.provide(relayTestLayer));
+ });
+
it("clears registration status on cloud sign-out", () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
setAgentAwarenessRelayTokenProvider(null);
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -74,6 +74,13 @@
let registrationStatus: AgentAwarenessRegistrationStatus = "unknown";
const registrationStatusListeners = new Set<() => void>();
+// Registration runs both through the background queue and directly via
+// refreshAgentAwarenessRegistration, so attempts can overlap. Counting relay
+// acceptances lets a failing attempt detect that a concurrent attempt already
+// succeeded while it was in flight, so a stale failure cannot overwrite a
+// registration the relay accepted.
+let registrationSuccessCount = 0;
+
function setRegistrationStatus(next: AgentAwarenessRegistrationStatus): void {
if (registrationStatus === next) {
return;
@@ -84,6 +91,18 @@
}
}
+function markRegistrationSucceeded(): void {
+ registrationSuccessCount++;
+ setRegistrationStatus("registered");
+}
+
+function markRegistrationFailed(successCountAtAttemptStart: number): void {
+ if (registrationSuccessCount !== successCountAtAttemptStart) {
+ return;
+ }
+ setRegistrationStatus("failed");
+}
+
export function getAgentAwarenessRegistrationStatus(): AgentAwarenessRegistrationStatus {
return registrationStatus;
}
@@ -316,7 +335,7 @@
catch: (cause) => cause,
}).pipe(Effect.orElseSucceed(() => null));
if (persisted && persisted.identity === identity && persisted.signature === signature) {
- setRegistrationStatus("registered");
+ markRegistrationSucceeded();
logRegistrationDebug("relay device registration skipped; already registered for account", {
expectedGeneration,
});
@@ -331,7 +350,7 @@
clerkToken: token,
payload: body,
});
- setRegistrationStatus("registered");
+ markRegistrationSucceeded();
yield* Effect.promise(() =>
saveAgentAwarenessRegistrationRecord({ identity, signature }).catch((error: unknown) => {
logRegistrationError("persist registration record failed", error);
@@ -466,11 +485,12 @@
};
activeDeviceRegistration = registration;
registration.operation = (async () => {
+ const successCountAtStart = registrationSuccessCount;
const result = await settleAsyncResult(() =>
runtime.runPromiseExit(registerDevice(next.input, generation)),
);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setRegistrationStatus("failed");
+ markRegistrationFailed(successCountAtStart);
logRegistrationError(next.context, squashAtomCommandFailure(result));
}
logRegistrationDebug("device registration finished", { generation });
@@ -675,14 +695,17 @@
never,
ManagedRelay.ManagedRelayClient
> {
- return registerDeviceForCurrentUser().pipe(
- Effect.catch((error) =>
- Effect.sync(() => {
- setRegistrationStatus("failed");
- logRegistrationError("device registration refresh failed", error);
- }),
- ),
- );
+ return Effect.suspend(() => {
+ const successCountAtStart = registrationSuccessCount;
+ return registerDeviceForCurrentUser().pipe(
+ Effect.catch((error) =>
+ Effect.sync(() => {
+ markRegistrationFailed(successCountAtStart);
+ logRegistrationError("device registration refresh failed", error);
+ }),
+ ),
+ );
+ });
}
export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
@@ -703,6 +726,7 @@
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
registrationStatus = "unknown";
+ registrationSuccessCount = 0;
registrationStatusListeners.clear();
}You can send follow-ups to the cloud agent here.
- Per-device APNs routing (bundle id + environment) so preview/dev builds receive pushes from the shared relay (+ migration for the new columns) - Settings notification/Live Activity toggles reflect actual relay registration success; cannot read as enabled when the device is not registered - Persistent register-once: skip re-registering an unchanged payload for the same account across launches; clear only on sign-out - Headless publish + tunnel-free linking: `t3 connect publish` toggles agent activity publishing and auto-establishes a publish-only (no managed tunnel) link, and `t3 connect link --publish-only` links for publishing alone, so activity flows to mobile clients that reach the environment out of band (e.g. Tailscale) without T3 Connect. Relay accepts publish-only links with a nominal endpoint; env proofs advertise the manual provider kind - Foreground Live Activity refresh, stale/ghost-row hardening, dedup fixes - Tighten widget deep linking and per-row rendering for active agents Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a31735c to
9c3eb7b
Compare
| // Development builds are Xcode-signed and receive sandbox APNs tokens; | ||
| // preview and production builds are distribution-signed and use production | ||
| // APNs. The relay routes each device's pushes accordingly. | ||
| export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" { |
There was a problem hiding this comment.
🟡 Medium agent-awareness/registrationPayload.ts:8
resolveApsEnvironment returns "production" for every value except the literal string "development". Locally run ios:preview and ios:prod builds launched via expo run:ios are development-signed and receive sandbox APNs tokens, but this function classifies them as "production". The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or Configuration/ entitlements) rather than the variant name, or document why locally-run preview/prod builds are expected to use production APNs.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:
`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.
| primaryCloudLinkState.refresh(); | ||
| const refreshResult = await refreshRelayEnvironments(); | ||
| if (refreshResult._tag === "Failure") { | ||
| if (!isAtomCommandInterrupted(refreshResult)) { | ||
| reportUpdateFailure(squashAtomCommandFailure(refreshResult)); | ||
| } | ||
| setIsUpdating(false); | ||
| return; | ||
| if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) { | ||
| reportUpdateFailure(squashAtomCommandFailure(refreshResult)); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
🟡 Medium settings/ConnectionsSettings.tsx:1745
When reconcileCloudState completes a link/unlink and preference update successfully, the mutation is already committed and primaryCloudLinkState.refresh() has been called. If the follow-up refreshRelayEnvironments() call at line 1747 then fails, the function returns false, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning true before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.
primaryCloudLinkState.refresh();
+ return true;
const refreshResult = await refreshRelayEnvironments();
if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
reportUpdateFailure(squashAtomCommandFailure(refreshResult));
return false;
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/settings/ConnectionsSettings.tsx around lines 1745-1750:
When `reconcileCloudState` completes a link/unlink and preference update successfully, the mutation is already committed and `primaryCloudLinkState.refresh()` has been called. If the follow-up `refreshRelayEnvironments()` call at line 1747 then fails, the function returns `false`, shows an error toast, and suppresses the success toast — so the user is told their T3 Connect change failed even though it was actually applied. Consider returning `true` before the relay refresh (or not reporting its failure as a mutation failure), so transient relay-discovery errors don't mask a successful mutation.
There was a problem hiding this comment.
🟠 High cloud/http.ts:369
makeCloudLinkProof calls isAllowedEndpointOrigin for every link proof, including manual providerKind requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with Invalid managed endpoint origin., blocking the out-of-band endpoint flow that isSupportedLinkProviderKind was added to support. Consider skipping the loopback origin check when providerKind === "manual".
if (
!isSupportedLinkProviderKind(request) ||
(request.endpoint.providerKind === "cloudflare_tunnel" &&
!isAllowedEndpointOrigin({
origin: request.origin,
requestUrl,
}))
) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around lines 369-375:
`makeCloudLinkProof` calls `isAllowedEndpointOrigin` for every link proof, including manual `providerKind` requests. That helper only permits loopback hosts, so a manual or publish-only link from a non-loopback origin (e.g. Tailscale or directly exposed host) is rejected with `Invalid managed endpoint origin.`, blocking the out-of-band endpoint flow that `isSupportedLinkProviderKind` was added to support. Consider skipping the loopback origin check when `providerKind === "manual"`.
…re-registration spam
- Rework the AgentActivity layout: attention-first row ordering, single-line
rows (title + inline project + status) shared by the banner and expanded
Dynamic Island, centered dot-separated banner headline, up to 5 banner rows,
a dedicated watch/CarPlay bannerSmall, and a phase-glyph minimal view for
the shared island
- Match status tints to the web sidebar pills (Sidebar.logic.ts), switching
between the light (-600) and dark (-300) palette off the activity color
scheme: iPhone renders on dark material, but macOS mirroring/notification
center renders on light where the dark palette was illegible
- Align the relay's "Starting" status label to the web sidebar's "Connecting"
- Ship the branded T3 mark to the widget extension: expo-widgets generates the
target with no Resources phase and hand-added ones are never scheduled, so
withWidgetLogoAsset.cjs installs the SVG template image set and an
alwaysOutOfDate actool shell phase (scripts/wire-widget-asset-catalog.cjs
applies the same wiring to an already-generated ios/ without a prebuild)
- Register a Live Activity push token with the relay only once per accepted
token: the refresh runs on sign-in, app foreground, and every environment
connection update, and was re-POSTing identical {deviceId, token} payloads
in a loop; the register-once set clears on sign-out/identity change
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // Any registered scheme variant routes back to this app; taps are delivered | ||
| // to the widget's containing app, so the prod scheme is safe for all builds. | ||
| const deepLinkRow = attentionRow ?? row0; | ||
| const deepLink = |
There was a problem hiding this comment.
🟡 Medium widgets/AgentActivity.tsx:140
The deep-link guard at deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") accepts any path, so values like /settings or /threads/env/thread?x=1 are turned into t3code://settings or t3code://threads/env/thread?x=1. Unlike extractAgentNotificationDeepLink, this allows non-thread paths and query/hash-bearing links to route Live Activity taps to the wrong screen. Consider matching the app's existing normalization so only valid thread links produce a deepLink.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/widgets/AgentActivity.tsx around line 140:
The deep-link guard at `deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")` accepts any path, so values like `/settings` or `/threads/env/thread?x=1` are turned into `t3code://settings` or `t3code://threads/env/thread?x=1`. Unlike `extractAgentNotificationDeepLink`, this allows non-thread paths and query/hash-bearing links to route Live Activity taps to the wrong screen. Consider matching the app's existing normalization so only valid thread links produce a `deepLink`.
Two live repros from phone-created threads: - Creating a thread and immediately backgrounding produced no card: the arm call ran after `await createProjectThread`, and by then the app had left the foreground, where ActivityKit rejects starts. Arm at submit time instead — the seed content is already known — and a failed creation self-corrects because the token registration's replay finds no work and ends the card. - Two cards at once: the app-open reconcile checks for existing activities, then yields for the relay snapshot request; an arm-on-send landing inside that window made both paths see "no card" and both start one. The reconcile now re-checks instances after the snapshot await and adopts the meanwhile-armed card, and any duplicate that does slip through gets ended (keep-first) on the next reconcile pass, which also cleans up cards stranded by earlier builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>)); | ||
| if (armedMeanwhile.length > 0) { | ||
| activities = [...armedMeanwhile]; | ||
| } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { |
There was a problem hiding this comment.
🟡 Medium agent-awareness/remoteRegistration.ts:1020
After sign-out, refreshActiveLiveActivityRemoteRegistration() can still start a Live Activity, leaving an orphaned card on the lock screen that no signed-in user can update or end. The guard at the top only checks relayTokenProvider once before readAgentActivitySnapshot() yields; when sign-out runs meanwhile, setAgentAwarenessRelayTokenProvider(null) calls endLocalLiveActivities(), but the generator resumes and calls AgentActivity.start(...) anyway, creating a fresh card after cleanup. Consider re-checking relayTokenProvider (or deviceRegistrationGeneration) after the snapshot await and before AgentActivity.start.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/remoteRegistration.ts around line 1020:
After sign-out, `refreshActiveLiveActivityRemoteRegistration()` can still start a Live Activity, leaving an orphaned card on the lock screen that no signed-in user can update or end. The guard at the top only checks `relayTokenProvider` once before `readAgentActivitySnapshot()` yields; when sign-out runs meanwhile, `setAgentAwarenessRelayTokenProvider(null)` calls `endLocalLiveActivities()`, but the generator resumes and calls `AgentActivity.start(...)` anyway, creating a fresh card after cleanup. Consider re-checking `relayTokenProvider` (or `deviceRegistrationGeneration`) after the snapshot await and before `AgentActivity.start`.
There was a problem hiding this comment.
Effect Service Conventions: one finding in the new ApnsProviderTokens service module. See inline comment.
Posted via Macroscope — Effect Service Conventions
Live repro: answering an agent's question left the card frozen on "Input". The publish stream showed why — the thread's resolved awareness state flapped to null during the answer handoff (running, deleted, running, deleted), and each null publish removed the thread row, emptied the aggregate, and ended the armed card mid-conversation. Under push-to-start the next running publish would have resurrected a card; with app-armed activities a spurious tombstone is fatal until the next app open. - The env server no longer tombstones a thread that was live at its last publish: the null resolution is deferred five seconds and re-resolved, publishing only if it still holds. Genuine deletions propagate a few seconds later; transient handoff gaps and shell lookup races never fire. - A relay end with no final content-state used to leave the card's last frame (that stale "Input") on the lock screen for the full five-minute dismissal window; contentless ends now dismiss in seconds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Deferred tombstone bypasses publish queue
- Deferred tombstone confirmations now re-enter the shared publish worker after the delay (serializing them against event-driven publishes) and a pending-confirmation set dedupes deferred timers per thread.
Or push these changes by commenting:
@cursor push 93976e2ee4
Preview (93976e2ee4)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -272,6 +272,7 @@
const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
const activeSnapshotPublishedRef = yield* Ref.make(false);
const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>());
+ const pendingTombstoneConfirmsRef = yield* Ref.make(new Set<ThreadId>());
const readSecretString = (name: string) =>
secrets
@@ -306,7 +307,7 @@
transformClient: relayEnvironmentClient(relayConfig.environmentCredential),
}).pipe(Effect.provide(FetchHttpClient.layer));
- // Assigned after publishThreadUnsafe below; indirection keeps the deferred
+ // Assigned after the publish worker below; indirection keeps the deferred
// tombstone confirmation from making the definition self-referential.
let confirmTombstoneLater: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void;
@@ -408,6 +409,20 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
+ const alreadyPending = yield* Ref.modify(pendingTombstoneConfirmsRef, (pending) => {
+ if (pending.has(threadId)) {
+ return [true, pending] as const;
+ }
+ return [false, new Set(pending).add(threadId)] as const;
+ });
+ if (alreadyPending) {
+ yield* Effect.logDebug("agent activity tombstone confirmation already pending", {
+ environmentId,
+ threadId,
+ reason: snapshot.reason,
+ });
+ return;
+ }
yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {
environmentId,
threadId,
@@ -442,23 +457,15 @@
});
});
- confirmTombstoneLater = (threadId) =>
- publishThreadUnsafe(threadId, { confirmTombstone: true }).pipe(
- Effect.delay("5 seconds"),
- Effect.catchCause((cause) =>
- Effect.logWarning("deferred agent activity tombstone failed", {
- threadId,
- cause: Cause.pretty(cause),
- }),
- ),
- Effect.asVoid,
- );
-
- const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) =>
- publishThreadUnsafe(threadId).pipe(
+ const runPublishTask = (task: {
+ readonly threadId: ThreadId;
+ readonly confirmTombstone?: boolean;
+ }) =>
+ publishThreadUnsafe(task.threadId, { confirmTombstone: task.confirmTombstone === true }).pipe(
Effect.catchCause((cause) => {
return Effect.logWarning("agent activity publish failed", {
- threadId,
+ threadId: task.threadId,
+ confirmTombstone: task.confirmTombstone === true,
cause: Cause.pretty(cause),
});
}),
@@ -466,6 +473,9 @@
withRelayClientTracing,
);
+ const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) =>
+ runPublishTask({ threadId });
+
const publishActiveThreadsUnsafe = Effect.gen(function* () {
const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(
Effect.orElseSucceed(() => false),
@@ -515,8 +525,24 @@
}
});
- const worker = yield* makeDrainableWorker(publishThread);
+ const worker = yield* makeDrainableWorker(runPublishTask);
+ // Deferred confirmations re-enter the same publish worker so they serialize
+ // against event-driven publishes instead of racing them from a detached
+ // fiber; only the delay itself runs outside the queue.
+ confirmTombstoneLater = (threadId) =>
+ Effect.sleep("5 seconds").pipe(
+ Effect.andThen(
+ Ref.update(pendingTombstoneConfirmsRef, (pending) => {
+ const next = new Set(pending);
+ next.delete(threadId);
+ return next;
+ }),
+ ),
+ Effect.andThen(worker.enqueue({ threadId, confirmTombstone: true })),
+ Effect.asVoid,
+ );
+
const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")(
function* () {
const [relayConfig, publishEnabled] = yield* Effect.all([
@@ -567,7 +593,7 @@
return Effect.logDebug("agent activity publishing queued thread publish", {
eventType: event.type,
threadId,
- }).pipe(Effect.andThen(worker.enqueue(threadId)));
+ }).pipe(Effect.andThen(worker.enqueue({ threadId })));
}),
);
},You can send follow-ups to the cloud agent here.
|
Bugbot Autofix prepared fixes for all 5 issues found in the latest run.
Or push these changes by commenting: Preview (6fb7435bba)diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -108,6 +108,7 @@
};
}
let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
+let armLiveActivityForLocalWorkInFlight = false;
let relayTokenProvider: (() => Promise<string | null>) | null = null;
let relayTokenProviderIdentity: string | null = null;
let deviceRegistrationGeneration = 0;
@@ -438,8 +439,9 @@
// Arms the lock-screen card the moment the user starts agent work from this
// phone, while the app is still foregrounded and the fresh activity's token
// can be registered immediately. The seeded row is a best-effort placeholder;
-// the relay's registration replay repaints it with the authoritative
-// aggregate within seconds. No-ops when a card is already armed.
+// the relay repaints it with the authoritative aggregate once the work's
+// first publish lands. No-ops when a card is already armed or the user
+// disabled Live Activity updates in settings.
export function armAgentAwarenessLiveActivityForLocalWork(input: {
readonly threadTitle: string;
readonly projectTitle: string;
@@ -447,40 +449,61 @@
if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
return;
}
- try {
- if (AgentActivity.getInstances().length > 0) {
- return;
+ // Reserve arming before the preference load yields: two quick sends on
+ // different threads would otherwise both observe zero instances and each
+ // start a card, while the relay tracks exactly one card per device.
+ if (armLiveActivityForLocalWorkInFlight) {
+ return;
+ }
+ armLiveActivityForLocalWorkInFlight = true;
+ void (async () => {
+ try {
+ // Honor the saved settings toggle before creating anything; without
+ // readable preferences the registration pipeline cannot run either, so
+ // an armed card would never receive updates.
+ const preferences = await loadPreferences().catch((error: unknown) => {
+ logRegistrationError("live activity arming preference load failed", error);
+ return null;
+ });
+ if (preferences === null || preferences.liveActivitiesEnabled === false) {
+ return;
+ }
+ if (AgentActivity.getInstances().length > 0) {
+ return;
+ }
+ const nowIso = new Date(Date.now()).toISOString();
+ const activity = AgentActivity.start({
+ title: "T3 Code",
+ subtitle: "Agent work in progress",
+ activeCount: 1,
+ updatedAt: nowIso,
+ activities: [
+ {
+ environmentId: "",
+ threadId: "",
+ projectTitle: input.projectTitle,
+ threadTitle: input.threadTitle,
+ modelTitle: "",
+ phase: "starting",
+ status: "Connecting",
+ updatedAt: nowIso,
+ deepLink: "/",
+ },
+ ],
+ });
+ logRegistrationDebug("live activity card armed for local work", {
+ threadTitle: input.threadTitle,
+ });
+ runRegistrationInBackground(
+ registerLiveActivityPushToken({ activity, armedForLocalWork: true }).pipe(Effect.asVoid),
+ "live activity arming after local task start failed",
+ );
+ } catch (error) {
+ logRegistrationError("live activity arming failed", error);
+ } finally {
+ armLiveActivityForLocalWorkInFlight = false;
}
- const nowIso = new Date(Date.now()).toISOString();
- const activity = AgentActivity.start({
- title: "T3 Code",
- subtitle: "Agent work in progress",
- activeCount: 1,
- updatedAt: nowIso,
- activities: [
- {
- environmentId: "",
- threadId: "",
- projectTitle: input.projectTitle,
- threadTitle: input.threadTitle,
- modelTitle: "",
- phase: "starting",
- status: "Connecting",
- updatedAt: nowIso,
- deepLink: "/",
- },
- ],
- });
- logRegistrationDebug("live activity card armed for local work", {
- threadTitle: input.threadTitle,
- });
- runRegistrationInBackground(
- registerLiveActivityPushToken({ activity }).pipe(Effect.asVoid),
- "live activity arming after local task start failed",
- );
- } catch (error) {
- logRegistrationError("live activity arming failed", error);
- }
+ })();
}
function readAgentActivitySnapshot(): Effect.Effect<
@@ -821,6 +844,7 @@
deviceRegistrationGeneration++;
activeDeviceRegistration = null;
pendingDeviceRegistration = null;
+ armLiveActivityForLocalWorkInFlight = false;
registrationStatus = "unknown";
registrationStatusListeners.clear();
registeredActivityPushTokens.clear();
@@ -853,6 +877,7 @@
export function registerLiveActivityPushToken(input: {
readonly activity: LiveActivity<AgentActivityProps>;
+ readonly armedForLocalWork?: boolean;
}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
return Effect.gen(function* () {
if (!canRegisterRemoteLiveActivities()) {
@@ -893,6 +918,7 @@
runRegistrationInBackground(
registerLiveActivityPushTokenValue({
activityPushToken: event.pushToken,
+ armedForLocalWork: input.armedForLocalWork === true,
}),
"live activity token listener registration failed",
);
@@ -903,12 +929,14 @@
return yield* registerLiveActivityPushTokenValue({
activityPushToken,
+ armedForLocalWork: input.armedForLocalWork === true,
});
});
}
function registerLiveActivityPushTokenValue(input: {
readonly activityPushToken: string;
+ readonly armedForLocalWork: boolean;
}): Effect.Effect<boolean, unknown, ManagedRelay.ManagedRelayClient> {
return Effect.gen(function* () {
const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken);
@@ -929,6 +957,7 @@
const registered = yield* registerLiveActivityWithRelay({
deviceId,
activityPushToken: input.activityPushToken,
+ ...(input.armedForLocalWork ? { armedForLocalWork: true } : {}),
});
if (registered) {
registeredActivityPushTokens.set(input.activityPushToken, Date.now());
@@ -1017,7 +1046,10 @@
}).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>));
if (armedMeanwhile.length > 0) {
activities = [...armedMeanwhile];
- } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) {
+ } else if (snapshot?.aggregate) {
+ // Any non-null aggregate is worth a card: the relay only returns
+ // one when there is content to show — live agents, or recently
+ // finished work still inside its Done/Failed display window.
const aggregate = snapshot.aggregate;
const primed = yield* Effect.try({
try: () =>
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -528,8 +528,8 @@
flow.setSubmitting(true);
// Arm the lock-screen card before the async thread creation: backgrounding
// the app right after tapping submit would otherwise reject the foreground
- // -only Activity start. If creation fails, the token registration's replay
- // finds no work and ends the card within seconds.
+ // -only Activity start. If creation fails, the next reconcile or publish
+ // with nothing to show ends the stranded card.
armAgentAwarenessLiveActivityForLocalWork({
threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
projectTitle: selectedProject.title,
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -42,6 +42,7 @@
readonly replayForLiveActivityRegistration: (input: {
readonly userId: string;
readonly deviceId: string;
+ readonly armedForLocalWork?: boolean;
}) => Effect.Effect<RelayDeliveryResult | null, AgentActivityPublishError>;
}
>()("t3code-relay/agentActivity/AgentActivityPublisher") {}
@@ -125,6 +126,15 @@
terminalState: null,
nowMs: now.epochMilliseconds,
});
+ // A registration for a card the app just armed for locally started
+ // work races that work's first publish: its rows usually do not exist
+ // yet, so an aggregate without live agents means "not published yet",
+ // not "nothing to show". Delivering here would end the fresh card (and
+ // drop its token) or overwrite the placeholder with stale Done rows —
+ // skip and let the imminent publish paint it instead.
+ if (input.armedForLocalWork === true && (aggregate === null || aggregate.activeCount === 0)) {
+ return null;
+ }
return yield* apnsDeliveries.sendForTarget({
target,
aggregate,
diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts
--- a/infra/relay/src/agentActivity/MobileRegistrations.ts
+++ b/infra/relay/src/agentActivity/MobileRegistrations.ts
@@ -78,6 +78,7 @@
.replayForLiveActivityRegistration({
userId: input.userId,
deviceId: input.payload.deviceId,
+ ...(input.payload.armedForLocalWork ? { armedForLocalWork: true } : {}),
})
.pipe(
Effect.tapError((error) =>
diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts
--- a/packages/contracts/src/relay.ts
+++ b/packages/contracts/src/relay.ts
@@ -84,6 +84,11 @@
export const RelayLiveActivityRegistrationRequest = Schema.Struct({
deviceId: TrimmedNonEmptyString,
activityPushToken: TrimmedNonEmptyString,
+ // Set when the app armed this activity for work it just started locally.
+ // The registration replay then races the first agent-activity publish, so
+ // an aggregate without live work means "not published yet", not "nothing
+ // to show" — the relay must not end or repaint the freshly armed card.
+ armedForLocalWork: Schema.optional(Schema.Boolean),
});
export type RelayLiveActivityRegistrationRequest = typeof RelayLiveActivityRegistrationRequest.Type;You can send follow-ups to the cloud agent here. |
Arming on send registers the card's token immediately, and the registration replay can reach the relay before the environment's first publish for the brand-new thread. With end-on-empty, that replay saw an empty aggregate, ended the seconds-old card, and retired its token — so the running/input updates that arrived moments later had no address and the card sat orphaned at its seed "Connecting" content (observed live: card armed and ended at 05:36:38Z, one second before the thread's waiting_for_input row landed). An empty aggregate within two minutes of arming now delivers nothing, leaving the seed content in place for the state that is about to arrive; past the grace window, empty still ends the card as designed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Grace blocks legitimate activity end
- LiveActivities.register no longer resets remote_started_at when the same activity token is re-registered (foreground reconciliation), so the freshly-armed grace window measures from the true arming time and orphaned cards receive their live_activity_end once it expires.
Or push these changes by commenting:
@cursor push 5ab40bb88e
Preview (5ab40bb88e)
diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts
--- a/infra/relay/src/agentActivity/LiveActivities.test.ts
+++ b/infra/relay/src/agentActivity/LiveActivities.test.ts
@@ -109,10 +109,12 @@
remoteStartedAt: null,
}),
]);
+ // The claim skips this device's own row: it must stay intact so the
+ // upsert can tell a same-token re-registration apart from a fresh arm.
expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([
{
- sql: '"relay_live_activities"."activity_push_token" = $1',
- params: ["activity-push-token"],
+ sql: '(("relay_live_activities"."activity_push_token" = $1) and ((("relay_live_activities"."user_id" <> $2) or ("relay_live_activities"."device_id" <> $3))))',
+ params: ["activity-push-token", "user-2", "device-1"],
},
]);
expect(insertedValues).toEqual([
@@ -131,12 +133,20 @@
expect.objectContaining({
activityPushToken: "activity-push-token",
remoteStartQueuedAt: null,
- remoteStartedAt: expect.any(String),
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,
}),
);
+ // Re-registering the SAME token (foreground reconciliation) must not
+ // restart the arming clock, or the end-on-empty grace window would
+ // renew forever and orphaned cards would never receive their end.
+ const remoteStartedAtSet = dialect.sqlToQuery(
+ conflictConfigs[0]?.set?.remoteStartedAt as SQL,
+ );
+ expect(remoteStartedAtSet.sql.replace(/\s+/g, " ")).toBe(
+ 'case when "relay_live_activities"."activity_push_token" = excluded.activity_push_token then coalesce("relay_live_activities"."remote_started_at", excluded.remote_started_at) else excluded.remote_started_at end',
+ );
}).pipe(
Effect.provide(
LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts
--- a/infra/relay/src/agentActivity/LiveActivities.ts
+++ b/infra/relay/src/agentActivity/LiveActivities.ts
@@ -13,7 +13,7 @@
import * as Function from "effect/Function";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { and, eq, sql } from "drizzle-orm";
+import { and, eq, ne, or, sql } from "drizzle-orm";
import * as RelayDb from "../db.ts";
import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
@@ -141,6 +141,10 @@
const updatedAt = DateTime.formatIso(yield* DateTime.now);
const registration = input.registration;
+ // Claim the token from any OTHER row that still holds it (a token
+ // addresses exactly one activity). The device's own row is left
+ // untouched so the upsert below can tell a same-token re-registration
+ // apart from a fresh arm.
yield* db
.update(relayLiveActivities)
.set({
@@ -150,7 +154,15 @@
endedAt: updatedAt,
updatedAt,
})
- .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken));
+ .where(
+ and(
+ eq(relayLiveActivities.activityPushToken, registration.activityPushToken),
+ or(
+ ne(relayLiveActivities.userId, input.userId),
+ ne(relayLiveActivities.deviceId, registration.deviceId),
+ ),
+ ),
+ );
yield* db
.insert(relayLiveActivities)
@@ -171,7 +183,17 @@
set: {
activityPushToken: registration.activityPushToken,
remoteStartQueuedAt: null,
- remoteStartedAt: updatedAt,
+ // remote_started_at records when the CARD was armed, and the
+ // end-on-empty grace window keys off it. Foreground
+ // reconciliation re-registers the same token periodically;
+ // refreshing the arming time there would perpetually renew the
+ // grace and suppress the end an orphaned card is waiting for.
+ // Only a new token (a new activity) restarts the clock.
+ remoteStartedAt: sql`case
+ when ${relayLiveActivities.activityPushToken} = excluded.activity_push_token
+ then coalesce(${relayLiveActivities.remoteStartedAt}, excluded.remote_started_at)
+ else excluded.remote_started_at
+ end`,
endedAt: null,
lastAggregateJson: null,
lastLiveActivityDeliveryAt: null,You can send follow-ups to the cloud agent here.
The stuck-at-Working card: the thread's publish stream showed running → deleted with no completed publish in between, and the tombstone debounce correctly confirmed the null five seconds later — the post-completion state resolves to no phase PERSISTENTLY. Session teardown settles still-running turns by session status, and that write can race turn.completed, leaving latestTurn.state at "interrupted" for a turn that actually finished. The awareness ladder mapped interrupted turns to null, so quick finish-then-teardown threads were tombstoned instead of published as completed: the row vanished, the empty aggregate fell into the freshly-armed grace window, and the card froze on its last state with no further publish to correct it. completedAt survives the state-column race: a turn with a completion timestamp finished, whatever the settling wrote. The ladder now projects those as completed — the tombstone confirm publishes Done instead of deleting the thread — while genuinely interrupted turns (no completedAt) still resolve to null. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // that has a completion timestamp finished, whatever the state column says. | ||
| // Without this, quick finish-then-teardown threads resolve to null | ||
| // persistently and get tombstoned instead of published as completed. | ||
| if (thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null) { |
There was a problem hiding this comment.
🟡 Medium src/agentAwareness.ts:112
The new fallback at line 112 misclassifies cancelled runs as completed. Turns settled via cancellation paths (interrupt-requested or session ended with interrupted/stopped status) also get state: "interrupted" with a non-null completedAt, so the condition thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null matches them too. This causes resolveThreadAwarenessPhase to return "completed" for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a completed turn's state is overwritten by interrupted, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a completedAt should be treated as completed.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/shared/src/agentAwareness.ts around line 112:
The new fallback at line 112 misclassifies cancelled runs as `completed`. Turns settled via cancellation paths (interrupt-requested or session ended with `interrupted`/`stopped` status) also get `state: "interrupted"` with a non-null `completedAt`, so the condition `thread.latestTurn?.state === "interrupted" && thread.latestTurn.completedAt !== null` matches them too. This causes `resolveThreadAwarenessPhase` to return `"completed"` for runs the user actually cancelled, so users see stopped runs as successfully finished. If the intent is only to cover the teardown race where a `completed` turn's state is overwritten by `interrupted`, the guard needs to distinguish that case from genuine cancellations — for example, by checking the session status or a separate completion marker. Otherwise, consider documenting why interrupted turns with a `completedAt` should be treated as completed.
The stuck-at-Working repro survives the completedAt ladder fix: the confirmed tombstone five seconds after turn completion still resolves null, so the settled shell holds some combination the static analysis keeps missing. Instead of guessing another layer deep, the tombstone paths now log the exact fields the phase ladder reads (session status, latest turn id/state/completedAt, pendings) both when deferring and when a confirmation actually publishes — one repro pins the writer. Also collapses the deferral storm visible at thread birth (six confirm fibers forked in 600ms): one pending confirmation per thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Pending confirm set can leak
- Wrapped the check-add-fork sequence in a single uninterruptible region so a pendingTombstoneConfirms entry can only exist once the detached confirm fiber (whose ensuring finalizer owns the delete) is guaranteed to have been forked.
Or push these changes by commenting:
@cursor push abb02eb729
Preview (abb02eb729)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -432,20 +432,30 @@
// immediately deletes the thread from the lock-screen card mid-
// conversation. Defer, re-resolve, and only tombstone if it holds;
// genuinely deleted threads still propagate a few seconds later.
- if (pendingTombstoneConfirms.has(threadId)) {
- return;
- }
- pendingTombstoneConfirms.add(threadId);
- yield* Effect.logInfo("agent activity tombstone deferred pending confirmation", {
- environmentId,
- threadId,
- reason: snapshot.reason,
- shell: describeThreadShellForAwareness(thread),
- });
- yield* Effect.forkDetach(
- confirmTombstoneLater(threadId).pipe(
- Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),
- ),
+ // Check-add-fork must be atomic under interruption: a set entry without
+ // a forked confirm fiber (whose `ensuring` owns the delete) would
+ // suppress tombstone confirmation for this thread forever.
+ yield* Effect.uninterruptible(
+ Effect.suspend(() => {
+ if (pendingTombstoneConfirms.has(threadId)) {
+ return Effect.void;
+ }
+ pendingTombstoneConfirms.add(threadId);
+ return Effect.logInfo("agent activity tombstone deferred pending confirmation", {
+ environmentId,
+ threadId,
+ reason: snapshot.reason,
+ shell: describeThreadShellForAwareness(thread),
+ }).pipe(
+ Effect.andThen(
+ Effect.forkDetach(
+ confirmTombstoneLater(threadId).pipe(
+ Effect.ensuring(Effect.sync(() => pendingTombstoneConfirms.delete(threadId))),
+ ),
+ ),
+ ),
+ );
+ }),
);
return;
}You can send follow-ups to the cloud agent here.
Diagnostics from the stuck-at-Working repro finally exposed the data hole: the confirmed tombstone's shell showed sessionStatus "ready" with latestTurn entirely absent. Threads whose turns produce no checkpoint never get a projection_turns row, and thread.session-set clears latest_turn_id the moment the session settles (activeTurnId goes null) — so "completed" is unrepresentable through turns for quick Q&A threads. Their entire lifecycle rode on session status and pendings, and at completion nothing remained on the awareness ladder: tombstone instead of Done. A live session at "ready"/"idle" with nothing pending and nothing running now projects as completed. This intentionally diverges from the web sidebar, which resolves the same null but renders it as "no pill" — a fine default in a list, while the publisher's null means "remove from the lock-screen card". (The sidebar's own Completed pill needs latestTurn.completedAt and silently can't fire for these threads either; persisting turn rows for checkpoint-less turns is the deeper future fix for both surfaces.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The full lifecycle finally works end to end, with one wart: a duplicate Done push notification fired one second after thread creation. Sessions boot at "ready" before their first turn starts, so the new ready-means-completed projection produced a momentary completed state at birth — and since the freshly armed card's token wasn't registered yet, it fell through to the notification channel. Server restarts had the same shape at scale: the startup snapshot republished completed for every recently finished thread, each queuing a push notification. - Env server: completed as a thread's FIRST published state defers and re-resolves like a tombstone — at birth the confirm sees running and completed never publishes; genuinely at-rest threads still publish five seconds later. Fresh completions (previous state was live) are unaffected, keeping the buzz immediate. - Relay: terminal push notifications only fire when the completion is fresh (row updated within two minutes), so replays and restart snapshots repaint state without ringing the device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if (!activity) { | ||
| return null; | ||
| } | ||
| if (activity.phase === "completed" || activity.phase === "failed") { |
There was a problem hiding this comment.
🟡 Medium agentActivity/ApnsDeliveries.ts:321
notificationForAggregate drops terminal notifications for push-notification-only users when activity.updatedAt is more than two minutes old. activity.updatedAt is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first completed/failed push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., last_push_notification_delivery_at) rather than activity.updatedAt, so only devices that already received a recent terminal notification are suppressed.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/agentActivity/ApnsDeliveries.ts around line 321:
`notificationForAggregate` drops terminal notifications for push-notification-only users when `activity.updatedAt` is more than two minutes old. `activity.updatedAt` is the timestamp from the published aggregate state, not the time the notification was first delivered to this device. If relay publishing or replay is delayed by more than two minutes (e.g., after downtime or a late reconnect), the first `completed`/`failed` push notification for a notifications-only user is silently suppressed even though it was never delivered before. Consider gating the freshness check on the device's last-known delivery time (e.g., `last_push_notification_delivery_at`) rather than `activity.updatedAt`, so only devices that already received a recent terminal notification are suppressed.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: LA alerts skip terminal freshness
- Threaded nowMs into alertForNewlyTerminal and alertForTerminalAggregate and applied the shared TERMINAL_NOTIFICATION_FRESHNESS_MS window via a new isFreshTerminalRow helper, so stale replayed completions no longer alert through Live Activity updates or end events.
Or push these changes by commenting:
@cursor push c88cec1a49
Preview (c88cec1a49)
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
@@ -1525,15 +1525,30 @@
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
};
expect(
- ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),
+ ApnsDeliveries.alertForTerminalAggregate({
+ aggregate: terminalAggregate,
+ preferences,
+ nowMs: 0,
+ }),
).toEqual({ title: "Thread", body: "Done: Project" });
expect(
ApnsDeliveries.alertForTerminalAggregate({
aggregate: terminalAggregate,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
- expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();
+ expect(
+ ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),
+ ).toBeNull();
+ // A completion replayed long after the fact must not ring again.
+ expect(
+ ApnsDeliveries.alertForTerminalAggregate({
+ aggregate: terminalAggregate,
+ preferences,
+ nowMs: 10 * 60 * 1_000,
+ }),
+ ).toBeNull();
});
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toEqual({ title: "Thread", body: "Done: Project" });
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@
previousAggregate: aggregate,
nextAggregate: next,
preferences: { ...preferences, notifyOnCompletion: false },
+ nowMs: 0,
}),
).toBeNull();
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@
previousAggregate: null,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,6 +1594,7 @@
previousAggregate: next,
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
expect(
@@ -1583,7 +1602,18 @@
previousAggregate: { ...aggregate, activities: [attentionRow] },
nextAggregate: next,
preferences,
+ nowMs: 0,
}),
).toBeNull();
+ // A stale completion replayed after a restart (previous aggregate still
+ // shows the thread as running) must not ring the device again.
+ expect(
+ ApnsDeliveries.alertForNewlyTerminal({
+ previousAggregate: aggregate,
+ nextAggregate: next,
+ preferences,
+ nowMs: 10 * 60 * 1_000,
+ }),
+ ).toBeNull();
});
});
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts
--- a/infra/relay/src/agentActivity/ApnsDeliveries.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts
@@ -210,6 +210,20 @@
};
}
+// Completions replayed long after the fact (server restarts republish every
+// recently-finished thread) must not ring the device again — on any channel:
+// push notifications, Live Activity update alerts, and end alerts all honor
+// the same freshness window.
+const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
+
+function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {
+ const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {
+ onNone: () => null,
+ onSome: (dt) => dt.epochMilliseconds,
+ });
+ return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;
+}
+
// Alert copy for an update whose aggregate contains threads that finished
// (Done/Failed) since the previously delivered aggregate — the mid-flight
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@
readonly previousAggregate: RelayAgentActivityAggregateState | null;
readonly nextAggregate: RelayAgentActivityAggregateState;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
if (input.previousAggregate === null) {
return null;
@@ -230,6 +245,9 @@
if (row.phase !== "completed" && row.phase !== "failed") {
return false;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {
+ return false;
+ }
const previousPhase = previousPhases.get(row.threadId);
return (
previousPhase !== undefined &&
@@ -255,11 +273,15 @@
export function alertForTerminalAggregate(input: {
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly preferences: RelayAgentAwarenessPreferences | null;
+ readonly nowMs: number;
}): ApnsLiveActivityAlert | null {
const row = input.aggregate?.activities[0];
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
return null;
}
+ if (!isFreshTerminalRow(row, input.nowMs)) {
+ return null;
+ }
if (!alertAllowedForPhase(input.preferences, row.phase)) {
return null;
}
@@ -298,10 +320,6 @@
);
}
-// Completions replayed long after the fact (server restarts republish every
-// recently-finished thread) must not ring the device again.
-const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
-
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@
if (!activity) {
return null;
}
- if (activity.phase === "completed" || activity.phase === "failed") {
- const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {
- onNone: () => null,
- onSome: (dt) => dt.epochMilliseconds,
- });
- if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {
- return null;
- }
+ if (
+ (activity.phase === "completed" || activity.phase === "failed") &&
+ !isFreshTerminalRow(activity, input.nowMs)
+ ) {
+ return null;
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@
previousAggregate,
nextAggregate,
preferences,
+ nowMs: input.nowMs,
}),
}
: "suppressed";
@@ -1100,6 +1116,7 @@
: alertForTerminalAggregate({
aggregate: delivery.aggregate,
preferences: parsePreferences(input.target.preferences_json),
+ nowMs: input.nowMs,
})
: delivery.alert;
const result = yield* deliveryQueue.enqueueLiveActivity({You can send follow-ups to the cloud agent here.
…A pref gates Fixes the real findings from the PR review bots ahead of the TestFlight build: - Deferred publish confirmations (tombstones and first-state completions) now re-enqueue through the same drainable worker as every other publish instead of a detached fiber calling the publisher directly, so a confirmed tombstone can never race an in-flight live update; a deadline map replaces the pending set, which also guarantees a single scheduled confirmation per thread and clears when the state recovers. (cursor/macroscope High) - Newly-terminal transitions bypass the 15s live-activity update throttle: a completion landing in the same window as a new start keeps activeCount unchanged and would previously be suppressed, silently dropping the Done transition and its buzz. (macroscope High) - The newly-terminal alert honors the same 2-minute freshness rule as terminal push notifications, so replayed aggregates repaint without ringing. (cursor Medium) - Live Activity arming and priming treat an unset preference as enabled (the toggle's default): fresh installs now prime, and arm-on-send respects an explicit off. (cursor High/Medium) Remaining findings triaged as stale (superseded designs), intended behavior (replay painting recent Done, LA-disabled end retiring the token), or accepted edge cases (grace extension on re-registration, completion alerts beyond the 3-row display cap, ready-with-lastError mapping to Done, cancelled turns showing Done). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stale confirm deadline publishes tombstone
- Cleared the thread's publishConfirmDeadlines entry in the unchanged-identity early-return path so a recovered projection cancels the deferred confirmation, preventing a later transient null state from confirming immediately against a stale expired deadline.
Or push these changes by commenting:
@cursor push e02169b78e
Preview (e02169b78e)
diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts
--- a/apps/server/src/relay/AgentAwarenessRelay.ts
+++ b/apps/server/src/relay/AgentAwarenessRelay.ts
@@ -410,6 +410,11 @@
const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
if (publishedStateByThread.get(threadId) === publishIdentity) {
+ // The projection recovered to the last published state, so any deferred
+ // confirmation (tombstone or first-state completion) is moot. Clear the
+ // deadline so the next divergence gets a fresh deferral window instead
+ // of confirming immediately against a stale, expired deadline.
+ publishConfirmDeadlines.delete(threadId);
yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", {
environmentId,
threadId,You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 97ed442. Configure here.
…d state A recovered projection exits at the unchanged-identity dedupe, which sits above the confirmation block, so a deferred publish's deadline survived recovery. A much later transient null would then find the deadline already expired and publish its tombstone immediately, bypassing the deferral window entirely. The dedupe path now clears any pending deadline: the state is back at what was last published, so the deferred confirmation is moot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the Effect Service Conventions check: the service builds a plain object with no effectful acquisition, so wrapping it in Effect.sync just to feed Layer.effect was the make-only-to-force-Layer.effect shape the conventions call out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🚀 Expo continuous deployment is ready!
|


Summary
Testing
vp checkvp run typecheckvp testNote
High Risk
Touches relay registration, APNs routing, cloud link modes, and Live Activity lifecycle (including removing push-to-start); misconfiguration or partial deploy could break mobile notifications or mis-route pushes.
Overview
Improves mobile agent awareness end-to-end: relay device registration now sends bundle ID and sandbox vs production APNs routing, tracks whether the relay actually accepted registration (settings toggles and alerts follow that, not just iOS permission), skips duplicate registrations via a persisted signature, and re-registers Live Activity tokens on foreground with short-window deduping. Push-to-start registration is removed in favor of arming a card when the user sends a task (and priming from a relay activity snapshot when idle), plus cleanup on cloud sign-out and a
releaseAgentAwarenessRelayTokenProviderpath so auth UI remounts do not wipe registration or end activities.Agent Activity Live Activity UI is reworked (attention-first rows, phase tints aligned with web, deep links, branded T3 mark via new Expo config plugins and
actoolshell phase). expo-widgets gainsfrequentUpdatesfor higher update budget.T3 Connect vs publishing split: environments can link in
publish_onlymode (manual endpoint, no Cloudflare tunnel) via CLI (connect link --publish-only,connect publish) and web settings (separate toggles reconciled together). Link proofs accept manual providers with activity-only scopes; server publish path defers transient tombstones and first completed snapshots before publishing.Relay DB adds
bundle_idandaps_environmenton mobile devices (migration in diff).Reviewed by Cursor Bugbot for commit 6f0b32d. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add per-device APNs routing, JWT token caching, and publish-only link mode to Live Activity delivery
bundle_idandaps_environmentper device in the relay and uses them to route APNs pushes to the correct topic and environment per device rather than using global config.ApnsProviderTokensservice that caches APNs JWTs quantized to 45-minute windows, preventingTooManyProviderTokenUpdateserrors; uses deterministic ES256 signing via@noble/curves.publish_onlycloud link mode in the CLI (t3 connect publish) and web settings UI, allowing agent activity publishing without a managed Cloudflare tunnel; linking with this mode usesproviderKind: "manual"and deprovisions any existing tunnel allocation.AgentActivitywidget to sort rows attention-first, apply per-phase tints from the web palette, deep-link to the most relevant thread, show a branded logo, and display a phase glyph in minimal presentation.unknown/pending/registered/failed) to the settings UI so notification and Live Activity toggles only appear enabled when the device is actually registered.makeAggregateStatenow requires anowMsparameter andstatusForPhase('starting')returns'Connecting'instead of'Starting', which are breaking changes for callers.Macroscope summarized 6f0b32d.